# -*- coding: utf-8 -*-
import pytest
import json
from unittest import mock

from django.core.exceptions import ValidationError

from awx.api.versioning import reverse

from awx.main.models import InventorySource, Inventory, ActivityStream
from awx.main.utils.inventory_vars import update_group_variables


@pytest.fixture
def scm_inventory(inventory, project):
    with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
        inventory.inventory_sources.create(name='foobar', source='scm', source_project=project)
    return inventory


@pytest.mark.django_db
def test_inventory_source_notification_on_cloud_only(get, post, inventory_source_factory, user, notification_template):
    u = user('admin', True)

    cloud_is = inventory_source_factory("ec2")
    cloud_is.source = "ec2"
    cloud_is.save()

    not_is = inventory_source_factory("not_ec2")

    url = reverse('api:inventory_source_notification_templates_success_list', kwargs={'pk': not_is.id})
    response = post(url, dict(id=notification_template.id), u)
    assert response.status_code == 400


@pytest.mark.django_db
def test_inventory_source_unique_together_with_inv(inventory_factory):
    inv1 = inventory_factory('foo')
    inv2 = inventory_factory('bar')
    is1 = InventorySource(name='foo', source='file', inventory=inv1)
    is1.save()
    is2 = InventorySource(name='foo', source='file', inventory=inv1)
    with pytest.raises(ValidationError):
        is2.validate_unique()
    is2 = InventorySource(name='foo', source='file', inventory=inv2)
    is2.validate_unique()


@pytest.mark.django_db
def test_inventory_host_name_unique(scm_inventory, post, admin_user):
    inv_src = scm_inventory.inventory_sources.first()
    inv_src.groups.create(name='barfoo', inventory=scm_inventory)
    resp = post(
        reverse('api:inventory_hosts_list', kwargs={'pk': scm_inventory.id}),
        {
            'name': 'barfoo',
            'inventory_id': scm_inventory.id,
        },
        admin_user,
        expect=400,
    )

    assert resp.status_code == 400
    assert "A Group with that name already exists." in json.dumps(resp.data)


@pytest.mark.django_db
def test_inventory_host_list_ordering(scm_inventory, get, admin_user):
    # create 3 hosts, hit the inventory host list view 3 times and get the order visible there each time and compare
    inv_src = scm_inventory.inventory_sources.first()
    host1 = inv_src.hosts.create(name='1', inventory=scm_inventory)
    host2 = inv_src.hosts.create(name='2', inventory=scm_inventory)
    host3 = inv_src.hosts.create(name='3', inventory=scm_inventory)
    expected_ids = [host1.id, host2.id, host3.id]
    resp = get(
        reverse('api:inventory_hosts_list', kwargs={'pk': scm_inventory.id}),
        admin_user,
    ).data['results']
    host_list = [host['id'] for host in resp]
    assert host_list == expected_ids


@pytest.mark.django_db
def test_inventory_group_name_unique(scm_inventory, post, admin_user):
    inv_src = scm_inventory.inventory_sources.first()
    inv_src.hosts.create(name='barfoo', inventory=scm_inventory)
    resp = post(
        reverse('api:inventory_groups_list', kwargs={'pk': scm_inventory.id}),
        {
            'name': 'barfoo',
            'inventory_id': scm_inventory.id,
        },
        admin_user,
        expect=400,
    )

    assert resp.status_code == 400
    assert "A Host with that name already exists." in json.dumps(resp.data)


@pytest.mark.django_db
def test_inventory_group_list_ordering(scm_inventory, get, put, admin_user):
    # create 3 groups, hit the inventory groups list view 3 times and get the order visible there each time and compare
    inv_src = scm_inventory.inventory_sources.first()
    group1 = inv_src.groups.create(name='1', inventory=scm_inventory)
    group2 = inv_src.groups.create(name='2', inventory=scm_inventory)
    group3 = inv_src.groups.create(name='3', inventory=scm_inventory)
    expected_ids = [group1.id, group2.id, group3.id]
    group_ids = {}
    for x in range(3):
        resp = get(
            reverse('api:inventory_groups_list', kwargs={'pk': scm_inventory.id}),
            admin_user,
        ).data['results']
        group_ids[x] = [group['id'] for group in resp]
    assert group_ids[0] == group_ids[1] == group_ids[2] == expected_ids


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 200), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_edit_inventory(put, inventory, alice, role_field, expected_status_code):
    data = {
        'organization': inventory.organization.id,
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(inventory, role_field).members.add(alice)
    put(reverse('api:inventory_detail', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code)


@pytest.mark.django_db
def test_async_inventory_deletion(delete, get, inventory, alice):
    inventory.admin_role.members.add(alice)
    resp = delete(reverse('api:inventory_detail', kwargs={'pk': inventory.id}), alice)
    assert resp.status_code == 202
    assert ActivityStream.objects.filter(operation='delete').exists()

    resp = get(reverse('api:inventory_detail', kwargs={'pk': inventory.id}), alice)
    assert resp.status_code == 200
    assert resp.data.get('pending_deletion') is True


@pytest.mark.django_db
def test_async_inventory_duplicate_deletion_prevention(delete, get, inventory, alice):
    inventory.admin_role.members.add(alice)
    resp = delete(reverse('api:inventory_detail', kwargs={'pk': inventory.id}), alice)
    assert resp.status_code == 202

    resp = delete(reverse('api:inventory_detail', kwargs={'pk': inventory.id}), alice)
    assert resp.status_code == 400
    assert resp.data['error'] == 'Inventory is already pending deletion.'


@pytest.mark.django_db
def test_async_inventory_deletion_deletes_related_jt(delete, get, job_template, inventory, alice, admin):
    job_template.inventory = inventory
    job_template.save()
    assert job_template.inventory == inventory
    inventory.admin_role.members.add(alice)
    resp = delete(reverse('api:inventory_detail', kwargs={'pk': inventory.id}), alice)
    assert resp.status_code == 202

    resp = get(reverse('api:job_template_detail', kwargs={'pk': job_template.id}), admin)
    jdata = json.loads(resp.content)
    assert jdata['inventory'] is None


@pytest.mark.parametrize('order_by', ('extra_vars', '-extra_vars', 'extra_vars,pk', '-extra_vars,pk'))
@pytest.mark.django_db
def test_list_cannot_order_by_unsearchable_field(get, organization, alice, order_by):
    get(reverse('api:job_list'), alice, QUERY_STRING='order_by=%s' % order_by, expect=403)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 201), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_create_inventory_group(post, inventory, alice, role_field, expected_status_code):
    data = {
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(inventory, role_field).members.add(alice)
    post(reverse('api:inventory_groups_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 201), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_create_inventory_group_child(post, group, alice, role_field, expected_status_code):
    data = {
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(group.inventory, role_field).members.add(alice)
    post(reverse('api:group_children_list', kwargs={'pk': group.id}), data, alice, expect=expected_status_code)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 200), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_edit_inventory_group(put, group, alice, role_field, expected_status_code):
    data = {
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(group.inventory, role_field).members.add(alice)
    put(reverse('api:group_detail', kwargs={'pk': group.id}), data, alice, expect=expected_status_code)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 201), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_create_inventory_inventory_source(post, inventory, alice, role_field, expected_status_code):
    data = {'source': 'ec2', 'name': 'ec2-inv-source'}
    if role_field:
        getattr(inventory, role_field).members.add(alice)
    post(reverse('api:inventory_inventory_sources_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 204), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_delete_inventory_group(delete, group, alice, role_field, expected_status_code):
    if role_field:
        getattr(group.inventory, role_field).members.add(alice)
    delete(reverse('api:group_detail', kwargs={'pk': group.id}), alice, expect=expected_status_code)


@pytest.mark.django_db
def test_create_inventory_smartgroup(post, get, inventory, admin_user, organization):
    data = {'name': 'Group 1', 'description': 'Test Group'}
    smart_inventory = Inventory(name='smart', kind='smart', organization=organization, host_filter='inventory_sources__source=ec2')
    smart_inventory.save()
    post(reverse('api:inventory_groups_list', kwargs={'pk': smart_inventory.id}), data, admin_user)
    resp = get(reverse('api:inventory_groups_list', kwargs={'pk': smart_inventory.id}), admin_user)
    jdata = json.loads(resp.content)

    assert getattr(smart_inventory, 'kind') == 'smart'
    assert jdata['count'] == 0


@pytest.mark.django_db
def test_create_inventory_smart_inventory_sources(post, get, inventory, admin_user, organization):
    data = {'name': 'Inventory Source 1', 'description': 'Test Inventory Source'}
    smart_inventory = Inventory(name='smart', kind='smart', organization=organization, host_filter='inventory_sources__source=ec2')
    smart_inventory.save()
    post(reverse('api:inventory_inventory_sources_list', kwargs={'pk': smart_inventory.id}), data, admin_user)
    resp = get(reverse('api:inventory_inventory_sources_list', kwargs={'pk': smart_inventory.id}), admin_user)
    jdata = json.loads(resp.content)

    assert getattr(smart_inventory, 'kind') == 'smart'
    assert jdata['count'] == 0


@pytest.mark.django_db
def test_urlencode_host_filter(post, admin_user, organization):
    """
    Host filters saved on the model must correspond to the same result
    as when that host_filter is used in the URL as a querystring.
    That means that it must be url-encoded patterns like %22 for quotes
    must be escaped as the string is saved to the model.

    Expected host filter in this test would match a host such as:
    inventory.hosts.create(
        ansible_facts={"ansible_distribution_version": "7.4"}
    )
    """
    # Create smart inventory with host filter that corresponds to querystring
    post(
        reverse('api:inventory_list'),
        data={
            'name': 'smart inventory',
            'kind': 'smart',
            'organization': organization.pk,
            'host_filter': 'ansible_facts__ansible_distribution_version=%227.4%22',
        },
        user=admin_user,
        expect=201,
    )
    # Assert that the saved version of host filter has escaped ""
    si = Inventory.objects.get(name='smart inventory')
    assert si.host_filter == 'ansible_facts__ansible_distribution_version="7.4"'


@pytest.mark.django_db
def test_host_filter_unicode(post, admin_user, organization):
    post(
        reverse('api:inventory_list'),
        data={'name': 'smart inventory', 'kind': 'smart', 'organization': organization.pk, 'host_filter': u'ansible_facts__ansible_distribution=レッドハット'},
        user=admin_user,
        expect=201,
    )
    si = Inventory.objects.get(name='smart inventory')
    assert si.host_filter == u'ansible_facts__ansible_distribution=レッドハット'


@pytest.mark.django_db
@pytest.mark.parametrize("lookup", ['icontains', 'has_keys'])
def test_host_filter_invalid_ansible_facts_lookup(post, admin_user, organization, lookup):
    resp = post(
        reverse('api:inventory_list'),
        data={
            'name': 'smart inventory',
            'kind': 'smart',
            'organization': organization.pk,
            'host_filter': u'ansible_facts__ansible_distribution__{}=cent'.format(lookup),
        },
        user=admin_user,
        expect=400,
    )
    assert 'ansible_facts does not support searching with __{}'.format(lookup) in json.dumps(resp.data)


@pytest.mark.django_db
def test_host_filter_ansible_facts_exact(post, admin_user, organization):
    post(
        reverse('api:inventory_list'),
        data={
            'name': 'smart inventory',
            'kind': 'smart',
            'organization': organization.pk,
            'host_filter': 'ansible_facts__ansible_distribution__exact="CentOS"',
        },
        user=admin_user,
        expect=201,
    )


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 201), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_create_inventory_host(post, inventory, alice, role_field, expected_status_code):
    data = {
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(inventory, role_field).members.add(alice)
    post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code)


@pytest.mark.parametrize(
    "hosts,expected_status_code",
    [
        (1, 201),
        (2, 201),
        (3, 201),
    ],
)
@pytest.mark.django_db
def test_create_inventory_host_with_limits(post, admin_user, inventory, hosts, expected_status_code):
    # The per-Organization host limits functionality should be a no-op on AWX.
    inventory.organization.max_hosts = 2
    inventory.organization.save()
    for i in range(hosts):
        inventory.hosts.create(name="Existing host %i" % i)

    data = {'name': 'New name', 'description': 'Hello world'}
    post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), data, admin_user, expect=expected_status_code)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 201), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_create_inventory_group_host(post, group, alice, role_field, expected_status_code):
    data = {
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(group.inventory, role_field).members.add(alice)
    post(reverse('api:group_hosts_list', kwargs={'pk': group.id}), data, alice, expect=expected_status_code)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 200), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_edit_inventory_host(put, host, alice, role_field, expected_status_code):
    data = {
        'name': 'New name',
        'description': 'Hello world',
    }
    if role_field:
        getattr(host.inventory, role_field).members.add(alice)
    put(reverse('api:host_detail', kwargs={'pk': host.id}), data, alice, expect=expected_status_code)


@pytest.mark.django_db
def test_edit_inventory_host_with_limits(put, host, admin_user):
    # The per-Organization host limits functionality should be a no-op on AWX.
    inventory = host.inventory
    inventory.organization.max_hosts = 1
    inventory.organization.save()
    inventory.hosts.create(name='Alternate host')

    data = {'name': 'New name', 'description': 'Hello world'}
    put(reverse('api:host_detail', kwargs={'pk': host.id}), data, admin_user, expect=200)


@pytest.mark.parametrize("role_field,expected_status_code", [(None, 403), ('admin_role', 204), ('update_role', 403), ('adhoc_role', 403), ('use_role', 403)])
@pytest.mark.django_db
def test_delete_inventory_host(delete, host, alice, role_field, expected_status_code):
    if role_field:
        getattr(host.inventory, role_field).members.add(alice)
    delete(reverse('api:host_detail', kwargs={'pk': host.id}), alice, expect=expected_status_code)


# See companion test in tests/functional/test_rbac_inventory.py::test_inventory_source_update
@pytest.mark.parametrize("start_access,expected_status_code", [(True, 202), (False, 403)])
@pytest.mark.django_db
def test_inventory_update_access_called(post, inventory_source, alice, mock_access, start_access, expected_status_code):
    with mock_access(InventorySource) as mock_instance:
        mock_instance.can_start = mock.MagicMock(return_value=start_access)
        post(reverse('api:inventory_source_update_view', kwargs={'pk': inventory_source.id}), {}, alice, expect=expected_status_code)
        mock_instance.can_start.assert_called_once_with(inventory_source)


@pytest.mark.django_db
def test_inventory_source_vars_prohibition(post, inventory, admin_user):
    with mock.patch('awx.api.serializers.settings') as mock_settings:
        mock_settings.INV_ENV_VARIABLE_BLOCKED = ('FOOBAR',)
        r = post(
            reverse('api:inventory_source_list'),
            {'name': 'new inv src', 'source_vars': '{\"FOOBAR\": \"val\"}', 'inventory': inventory.pk},
            admin_user,
            expect=400,
        )
    assert 'prohibited environment variable' in r.data['source_vars'][0]
    assert 'FOOBAR' in r.data['source_vars'][0]


@pytest.mark.django_db
@pytest.mark.parametrize('role,expect', [('admin_role', 200), ('use_role', 403), ('adhoc_role', 403), ('read_role', 403)])
def test_action_view_permissions(patch, put, get, inventory, rando, role, expect):
    getattr(inventory, role).members.add(rando)
    url = reverse('api:inventory_variable_data', kwargs={'pk': inventory.pk})
    # read_role and all other roles should be able to view
    get(url=url, user=rando, expect=200)
    patch(url=url, data={"host_filter": "bar"}, user=rando, expect=expect)
    put(url=url, data={"fooooo": "bar"}, user=rando, expect=expect)


@pytest.mark.django_db
class TestInventorySourceCredential:
    def test_need_cloud_credential(self, inventory, admin_user, post):
        """Test that a cloud-based source requires credential"""
        r = post(url=reverse('api:inventory_source_list'), data={'inventory': inventory.pk, 'name': 'foo', 'source': 'openstack'}, expect=400, user=admin_user)
        assert 'Credential is required for a cloud source' in r.data['credential'][0]

    def test_ec2_no_credential(self, inventory, admin_user, post):
        """Test that an ec2 inventory source can be added with no credential"""
        post(url=reverse('api:inventory_source_list'), data={'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2'}, expect=201, user=admin_user)

    def test_validating_credential_type(self, organization, inventory, admin_user, post):
        """Test that cloud sources must use their respective credential type"""
        from awx.main.models.credential import Credential, CredentialType

        openstack = CredentialType.defaults['openstack']()
        openstack.save()
        os_cred = Credential.objects.create(credential_type=openstack, name='bar', organization=organization)
        r = post(
            url=reverse('api:inventory_source_list'),
            data={'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2', 'credential': os_cred.pk},
            expect=400,
            user=admin_user,
        )
        assert 'Cloud-based inventory sources (such as ec2)' in r.data['credential'][0]
        assert 'require credentials for the matching cloud service' in r.data['credential'][0]

    def test_vault_credential_not_allowed(self, project, inventory, vault_credential, admin_user, post):
        """Vault credentials cannot be associated via the deprecated field"""
        # TODO: when feature is added, add tests to use the related credentials
        # endpoint for multi-vault attachment
        r = post(
            url=reverse('api:inventory_source_list'),
            data={
                'inventory': inventory.pk,
                'name': 'fobar',
                'source': 'scm',
                'source_project': project.pk,
                'source_path': '',
                'credential': vault_credential.pk,
                'source_vars': 'plugin: a.b.c',
            },
            expect=400,
            user=admin_user,
        )
        assert 'Credentials of type insights and vault' in r.data['credential'][0]
        assert 'disallowed for scm inventory sources' in r.data['credential'][0]

    def test_vault_credential_not_allowed_via_related(self, project, inventory, vault_credential, admin_user, post):
        """Vault credentials cannot be associated via related endpoint"""
        inv_src = InventorySource.objects.create(inventory=inventory, name='foobar', source='scm', source_project=project, source_path='')
        r = post(url=reverse('api:inventory_source_credentials_list', kwargs={'pk': inv_src.pk}), data={'id': vault_credential.pk}, expect=400, user=admin_user)
        assert 'Credentials of type insights and vault' in r.data['msg']
        assert 'disallowed for scm inventory sources' in r.data['msg']

    def test_credentials_relationship_mapping(self, project, inventory, organization, admin_user, post, patch):
        """The credentials relationship is used to manage the cloud credential
        this test checks that replacement works"""
        from awx.main.models.credential import Credential, CredentialType

        openstack = CredentialType.defaults['openstack']()
        openstack.save()
        os_cred = Credential.objects.create(credential_type=openstack, name='bar', organization=organization)
        r = post(
            url=reverse('api:inventory_source_list'),
            data={
                'inventory': inventory.pk,
                'name': 'fobar',
                'source': 'scm',
                'source_project': project.pk,
                'source_path': '',
                'credential': os_cred.pk,
                'source_vars': 'plugin: a.b.c',
            },
            expect=201,
            user=admin_user,
        )
        aws = CredentialType.defaults['aws']()
        aws.save()
        aws_cred = Credential.objects.create(credential_type=aws, name='bar2', organization=organization)
        inv_src = InventorySource.objects.get(pk=r.data['id'])
        assert list(inv_src.credentials.values_list('id', flat=True)) == [os_cred.pk]
        patch(url=inv_src.get_absolute_url(), data={'credential': aws_cred.pk}, expect=200, user=admin_user)
        assert list(inv_src.credentials.values_list('id', flat=True)) == [aws_cred.pk]

    @pytest.mark.skip(reason="Delay until AAP-53978 completed")
    def test_vmware_cred_create_esxi_source(self, inventory, admin_user, organization, post, get):
        """Test that a vmware esxi source can be added with a vmware credential"""
        from awx.main.models.credential import Credential, CredentialType

        vmware = CredentialType.defaults['vmware']()
        vmware.save()
        vmware_cred = Credential.objects.create(credential_type=vmware, name="bar", organization=organization)
        inv_src = InventorySource.objects.create(inventory=inventory, name='foobar', source='vmware_esxi')
        r = post(url=reverse('api:inventory_source_credentials_list', kwargs={'pk': inv_src.pk}), data={'id': vmware_cred.pk}, expect=204, user=admin_user)
        g = get(inv_src.get_absolute_url(), admin_user)
        assert r.status_code == 204
        assert g.data['credential'] == vmware_cred.pk


@pytest.mark.django_db
class TestControlledBySCM:
    """
    Check that various actions are correctly blocked if object is controlled
    by an SCM follow-project inventory source
    """

    def test_safe_method_works(self, get, options, scm_inventory, admin_user):
        get(scm_inventory.get_absolute_url(), admin_user, expect=200)
        options(scm_inventory.get_absolute_url(), admin_user, expect=200)

    def test_vars_edit_reset(self, patch, scm_inventory, admin_user):
        patch(scm_inventory.get_absolute_url(), {'variables': 'hello: world'}, admin_user, expect=200)

    def test_name_edit_allowed(self, patch, scm_inventory, admin_user):
        patch(scm_inventory.get_absolute_url(), {'variables': '---', 'name': 'newname'}, admin_user, expect=200)

    def test_host_associations_reset(self, post, scm_inventory, admin_user):
        inv_src = scm_inventory.inventory_sources.first()
        h = inv_src.hosts.create(name='barfoo', inventory=scm_inventory)
        g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
        post(reverse('api:host_groups_list', kwargs={'pk': h.id}), {'id': g.id}, admin_user, expect=204)
        post(reverse('api:group_hosts_list', kwargs={'pk': g.id}), {'id': h.id}, admin_user, expect=204)

    def test_group_group_associations_reset(self, post, scm_inventory, admin_user):
        inv_src = scm_inventory.inventory_sources.first()
        g1 = inv_src.groups.create(name='barland', inventory=scm_inventory)
        g2 = inv_src.groups.create(name='fooland', inventory=scm_inventory)
        post(reverse('api:group_children_list', kwargs={'pk': g1.id}), {'id': g2.id}, admin_user, expect=204)

    def test_host_group_delete_reset(self, delete, scm_inventory, admin_user):
        inv_src = scm_inventory.inventory_sources.first()
        h = inv_src.hosts.create(name='barfoo', inventory=scm_inventory)
        g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
        delete(h.get_absolute_url(), admin_user, expect=204)
        delete(g.get_absolute_url(), admin_user, expect=204)

    def test_remove_scm_inv_src(self, delete, scm_inventory, admin_user):
        inv_src = scm_inventory.inventory_sources.first()
        delete(inv_src.get_absolute_url(), admin_user, expect=204)
        assert scm_inventory.inventory_sources.count() == 0

    def test_adding_inv_src_ok(self, post, scm_inventory, project, admin_user):
        post(
            reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}),
            {
                'name': 'new inv src',
                'source_project': project.pk,
                'source': 'scm',
                'overwrite_vars': True,
                'source_vars': 'plugin: a.b.c',
            },
            admin_user,
            expect=201,
        )

    def test_adding_inv_src_without_proj_access_prohibited(self, post, project, inventory, rando):
        inventory.admin_role.members.add(rando)
        post(
            reverse('api:inventory_inventory_sources_list', kwargs={'pk': inventory.id}),
            {'name': 'new inv src', 'source_project': project.pk, 'source': 'scm', 'overwrite_vars': True, 'source_vars': 'plugin: a.b.c'},
            rando,
            expect=403,
        )


@pytest.mark.django_db
class TestConstructedInventory:
    @pytest.fixture
    def constructed_inventory(self, organization):
        return Inventory.objects.create(name='constructed-test-inventory', kind='constructed', organization=organization)

    def test_get_constructed_inventory(self, constructed_inventory, admin_user, get):
        inv_src = constructed_inventory.inventory_sources.first()
        inv_src.update_cache_timeout = 53
        inv_src.save(update_fields=['update_cache_timeout'])
        r = get(url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), user=admin_user, expect=200)
        assert r.data['update_cache_timeout'] == 53

    def test_patch_constructed_inventory(self, constructed_inventory, admin_user, patch):
        inv_src = constructed_inventory.inventory_sources.first()
        assert inv_src.update_cache_timeout == 0
        assert inv_src.limit == ''
        r = patch(
            url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}),
            data=dict(update_cache_timeout=54, limit='foobar'),
            user=admin_user,
            expect=200,
        )
        assert r.data['update_cache_timeout'] == 54
        inv_src = constructed_inventory.inventory_sources.first()
        assert inv_src.update_cache_timeout == 54
        assert inv_src.limit == 'foobar'

    def test_patch_constructed_inventory_generated_source_limits_editable_fields(self, constructed_inventory, admin_user, project, patch):
        inv_src = constructed_inventory.inventory_sources.first()
        r = patch(
            url=inv_src.get_absolute_url(),
            data={
                'source': 'scm',
                'source_project': project.pk,
                'source_path': '',
                'source_vars': 'plugin: a.b.c',
            },
            expect=400,
            user=admin_user,
        )
        assert str(r.data['error'][0]) == "Cannot change field 'source' on a constructed inventory source."

        # Make sure it didn't get updated before we got the error
        inv_src_after_err = constructed_inventory.inventory_sources.first()
        assert inv_src.id == inv_src_after_err.id
        assert inv_src.source == inv_src_after_err.source
        assert inv_src.source_project == inv_src_after_err.source_project
        assert inv_src.source_path == inv_src_after_err.source_path
        assert inv_src.source_vars == inv_src_after_err.source_vars

    def test_patch_constructed_inventory_generated_source_allows_source_vars_edit(self, constructed_inventory, admin_user, patch):
        inv_src = constructed_inventory.inventory_sources.first()
        patch(
            url=inv_src.get_absolute_url(),
            data={
                'source_vars': 'plugin: a.b.c',
            },
            expect=200,
            user=admin_user,
        )

        inv_src_after_patch = constructed_inventory.inventory_sources.first()

        # sanity checks
        assert inv_src.id == inv_src_after_patch.id
        assert inv_src.source == 'constructed'
        assert inv_src_after_patch.source == 'constructed'
        assert inv_src.source_vars == ''

        assert inv_src_after_patch.source_vars == 'plugin: a.b.c'

    def test_create_constructed_inventory(self, constructed_inventory, admin_user, post, organization):
        r = post(
            url=reverse('api:constructed_inventory_list'),
            data=dict(name='constructed-inventory-just-created', kind='constructed', organization=organization.id, update_cache_timeout=55, limit='foobar'),
            user=admin_user,
            expect=201,
        )
        pk = r.data['id']
        constructed_inventory = Inventory.objects.get(pk=pk)
        inv_src = constructed_inventory.inventory_sources.first()
        assert inv_src.update_cache_timeout == 55
        assert inv_src.limit == 'foobar'

    def test_get_absolute_url_for_constructed_inventory(self, constructed_inventory, admin_user, get):
        """
        If we are using the normal inventory API endpoint to look at a
        constructed inventory, then we should get a normal inventory API route
        back. If we are accessing it via the special constructed inventory
        endpoint, then we should get that back.
        """

        url_const = reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk})
        url_inv = reverse('api:inventory_detail', kwargs={'pk': constructed_inventory.pk})

        const_r = get(url=url_const, user=admin_user, expect=200)
        inv_r = get(url=url_inv, user=admin_user, expect=200)
        assert const_r.data['url'] == url_const
        assert inv_r.data['url'] == url_inv
        assert inv_r.data['url'] != const_r.data['url']
        assert inv_r.data['related']['constructed_url'] == url_const
        assert const_r.data['related']['constructed_url'] == url_const


@pytest.mark.django_db
class TestInventoryAllVariables:

    @staticmethod
    def simulate_update_from_source(inv_src, variables_dict, overwrite_vars=True):
        """
        Update `inventory` with variables `variables_dict` from source
        `inv_src`.
        """
        # Perform an update from source the same way it is done in
        # `inventory_import.Command._update_inventory`.
        new_vars = update_group_variables(
            group_id=None,  # `None` denotes the 'all' group (which doesn't have a pk).
            newvars=variables_dict,
            dbvars=inv_src.inventory.variables_dict,
            invsrc_id=inv_src.id,
            inventory_id=inv_src.inventory.id,
            overwrite_vars=overwrite_vars,
        )
        inv_src.inventory.variables = json.dumps(new_vars)
        inv_src.inventory.save(update_fields=["variables"])
        return new_vars

    def update_and_verify(self, inv_src, new_vars, expect=None, overwrite_vars=True, teststep=None):
        """
        Helper: Update from source and verify the new inventory variables.

        :param inv_src: An inventory source object with its inventory property
            set to the inventory fixture of the called.
        :param dict new_vars: The variables of the inventory source `inv_src`.
        :param dict expect: (optional) The expected variables state of the
            inventory after the update. If not set or None, expect `new_vars`.
        :param bool overwrite_vars: The status of the inventory source option
            'overwrite variables'. Default is `True`.
        :raise AssertionError: If the inventory does not contain the expected
            variables after the update.
        """
        self.simulate_update_from_source(inv_src, new_vars, overwrite_vars=overwrite_vars)
        if teststep is not None:
            assert inv_src.inventory.variables_dict == (expect if expect is not None else new_vars), f"Test step {teststep}"
        else:
            assert inv_src.inventory.variables_dict == (expect if expect is not None else new_vars)

    def test_set_variables_through_inventory_details_update(self, inventory, patch, admin_user):
        """
        Set an inventory variable by changing the inventory details, simulating
        a user edit.
        """
        # a: x
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'a: x'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        assert inventory.variables_dict == {"a": "x"}

    def test_variables_set_by_user_persist_update_from_src(self, inventory, inventory_source, patch, admin_user):
        """
        Verify the special behavior that a variable which originates from a user
        edit (instead of a source update), is not removed from the inventory
        when a source update with overwrite_vars=True does not contain that
        variable. This behavior is considered special because a variable which
        originates from a source would actually be deleted.

        In addition, verify that an existing variable which was set by a user
        edit can be overwritten by a source update.
        """
        # Set two variables via user edit.
        patch(
            url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}),
            data={'variables': '{"a": "a_from_user", "b": "b_from_user"}'},
            user=admin_user,
            expect=200,
        )
        inventory.refresh_from_db()
        assert inventory.variables_dict == {'a': 'a_from_user', 'b': 'b_from_user'}
        # Update from a source which contains only one of the two variables from
        # the previous update.
        self.simulate_update_from_source(inventory_source, {'a': 'a_from_source'})
        # Verify inventory variables.
        assert inventory.variables_dict == {'a': 'a_from_source', 'b': 'b_from_user'}

    def test_variables_set_through_src_get_removed_on_update_from_same_src(self, inventory, inventory_source, patch, admin_user):
        """
        Verify that a variable which originates from a source update, is removed
        from the inventory when a source update with overwrite_vars=True does
        not contain that variable.

        In addition, verify that an existing variable which was set by a user
        edit can be overwritten by a source update.
        """
        # Set two variables via update from source.
        self.simulate_update_from_source(inventory_source, {'a': 'a_from_source', 'b': 'b_from_source'})
        # Verify inventory variables.
        assert inventory.variables_dict == {'a': 'a_from_source', 'b': 'b_from_source'}
        # Update from the same source which now contains only one of the two
        # variables from the previous update.
        self.simulate_update_from_source(inventory_source, {'b': 'b_from_source'})
        # Verify the variable has been deleted from the inventory.
        assert inventory.variables_dict == {'b': 'b_from_source'}

    def test_overwrite_variables_through_inventory_details_update(self, inventory, patch, admin_user):
        """
        Set and update the inventory variables multiple times by changing the
        inventory details via api, simulating user edits.

        Any variables update by means of an inventory details update shall
        overwright all existing inventory variables.
        """
        # a: x
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'a: x'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        assert inventory.variables_dict == {"a": "x"}
        # a: x2
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'a: x2'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        assert inventory.variables_dict == {"a": "x2"}
        # b: y
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'b: y'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        assert inventory.variables_dict == {"b": "y"}

    def test_inventory_group_variables_internal_data(self, inventory, patch, admin_user):
        """
        Basic verification of how variable updates are stored internally.

        .. Warning::

            This test verifies a specific implementation of the inventory
            variables update business logic. It may deliver false negatives if
            the implementation changes.
        """
        # x: a
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'a: x'}, user=admin_user, expect=200)
        igv = inventory.inventory_group_variables.first()
        assert igv.variables == {'a': [[-1, 'x']]}
        # b: y
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'b: y'}, user=admin_user, expect=200)
        igv = inventory.inventory_group_variables.first()
        assert igv.variables == {'b': [[-1, 'y']]}

    def test_update_then_user_change(self, inventory, patch, admin_user, inventory_source):
        """
        1. Update inventory vars by means of an inventory source update.
        2. Update inventory vars by editing the inventory details (aka a 'user
           update'), thereby changing variables values and deleting variables
           from the inventory.

        .. Warning::

            This test partly relies on a specific implementation of the
            inventory variables update business logic. It may deliver false
            negatives if the implementation changes.
        """
        assert inventory_source.inventory_id == inventory.pk  # sanity
        # ---- Test step 1: Set variables by updating from an inventory source.
        self.simulate_update_from_source(inventory_source, {'foo': 'foo_from_source', 'bar': 'bar_from_source'})
        # Verify inventory variables.
        assert inventory.variables_dict == {'foo': 'foo_from_source', 'bar': 'bar_from_source'}
        # Verify internal storage of variables data. Note that this is
        # implementation specific
        assert inventory.inventory_group_variables.count() == 1
        igv = inventory.inventory_group_variables.first()
        assert igv.variables == {'foo': [[inventory_source.id, 'foo_from_source']], 'bar': [[inventory_source.id, 'bar_from_source']]}
        # ---- Test step 2: Change the variables by editing the inventory details.
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'foo: foo_from_user'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        # Verify that variable `foo` contains the new value, and that variable
        # `bar` has been deleted from the inventory.
        assert inventory.variables_dict == {"foo": "foo_from_user"}
        # Verify internal storage of variables data. Note that this is
        # implementation specific
        inventory.inventory_group_variables.count() == 1
        igv = inventory.inventory_group_variables.first()
        assert igv.variables == {'foo': [[-1, 'foo_from_user']]}

    def test_monotonic_deletions(self, inventory, patch, admin_user):
        """
        Verify the variables history logic for monotonic deletions.

        Monotonic in this context means that the variables are deleted in the
        reverse order of their creation.

        1. Set inventory variable x: 0, expect INV={x: 0}

        (The following steps use overwrite_variables=False)

        2. Update from source A={x: 1}, expect INV={x: 1}
        3. Update from source B={x: 2}, expect INV={x: 2}
        4. Update from source B={}, expect INV={x: 1}
        5. Update from source A={}, expect INV={x: 0}
        """
        inv_src_a = InventorySource.objects.create(name="inv-src-A", inventory=inventory, source="ec2")
        inv_src_b = InventorySource.objects.create(name="inv-src-B", inventory=inventory, source="ec2")
        # Test step 1:
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'x: 0'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        assert inventory.variables_dict == {"x": 0}
        # Test step 2: Source A overwrites value of var x
        self.update_and_verify(inv_src_a, {"x": 1}, teststep=2)
        # Test step 3: Source A overwrites value of var x
        self.update_and_verify(inv_src_b, {"x": 2}, teststep=3)
        # Test step 4: Value of var x from source A reappears
        self.update_and_verify(inv_src_b, {}, expect={"x": 1}, teststep=4)
        # Test step 5: Value of var x from initial user edit reappears
        self.update_and_verify(inv_src_a, {}, expect={"x": 0}, teststep=5)

    def test_interleaved_deletions(self, inventory, patch, admin_user, inventory_source):
        """
        Verify the variables history logic for interleaved deletions.

        Interleaved in this context means that the variables are deleted in a
        different order than the sequence of their creation.

        1. Set inventory variable x: 0, expect INV={x: 0}
        2. Update from source A={x: 1}, expect INV={x: 1}
        3. Update from source B={x: 2}, expect INV={x: 2}
        4. Update from source C={x: 3}, expect INV={x: 3}
        5. Update from source B={}, expect INV={x: 3}
        6. Update from source C={}, expect INV={x: 1}
        """
        inv_src_a = InventorySource.objects.create(name="inv-src-A", inventory=inventory, source="ec2")
        inv_src_b = InventorySource.objects.create(name="inv-src-B", inventory=inventory, source="ec2")
        inv_src_c = InventorySource.objects.create(name="inv-src-C", inventory=inventory, source="ec2")
        # Test step 1. Set inventory variable x: 0
        patch(url=reverse('api:inventory_detail', kwargs={'pk': inventory.pk}), data={'variables': 'x: 0'}, user=admin_user, expect=200)
        inventory.refresh_from_db()
        assert inventory.variables_dict == {"x": 0}
        # Test step 2: Source A overwrites value of var x
        self.update_and_verify(inv_src_a, {"x": 1}, teststep=2)
        # Test step 3: Source B overwrites value of var x
        self.update_and_verify(inv_src_b, {"x": 2}, teststep=3)
        # Test step 4: Source C overwrites value of var x
        self.update_and_verify(inv_src_c, {"x": 3}, teststep=4)
        # Test step 5: Value of var x from source C remains unchanged
        self.update_and_verify(inv_src_b, {}, expect={"x": 3}, teststep=5)
        # Test step 6: Value of var x from source A reappears, because the
        # latest update from source B did not contain var x.
        self.update_and_verify(inv_src_c, {}, expect={"x": 1}, teststep=6)
